# 新手指引组件封装

# 背景

当项目上线了大量新功能时,用户会觉得无从下手,这时就需要一个引导动画去指导用户使用。

# 操作提示栏

拓扑图有许多快捷键操作,不写出来是没人知道的,于是在页面顶部加一横幅。

# 使用idux-Alert

查看idux文档,发现Alert组件符合设计需求,但是其中的提示语得一行行写到template中,不是很方便,于是对Alert组件再次封装,添加配置化功能。

# 配置化

通过传入alertList参数,即可自动分页显示图标+文字的格式。

格式如下:

import mouseCenter from '~/images/equipment/topo/mouse-center.svg';
import mouseLeft from '~/images/equipment/topo/mouse-left.svg';
import mouseRight from '~/images/equipment/topo/mouse-right.svg';

export const AlertList = {
    list: [
        {
            itemList: [
                {
                    icon: mouseLeft,
                    text: _('左键单击设备(查看信息)')
                },
                {
                    icon: mouseLeft,
                    text: _('Ctrl+左键拖动框选(批量选择)')
                },
                {
                    icon: mouseLeft,
                    text: _('左键长按移动(拖动画布)')
                },
                {
                    icon: mouseRight,
                    text: _('右键设备(编辑)')
                },
                {
                    icon: mouseCenter,
                    text: _('滚轮(放大缩小)')
                },
            ]
        },
        {
            itemList: [
                {
                    text: _('撤销') + _(':') + 'Ctrl+Z'
                },
                {
                    text: _('反撤销') + _(':') + 'Ctrl+Shift+Z'
                },
                {
                    text: _('放大') + _(':') + 'Ctrl+[+]'
                },
                {
                    text: _('缩小') + _(':') + 'Ctrl+[-]'
                },
            ]
        },
    ]
};

接口定义:

interface AlertListItemDetail {
  icon?: string;
  text: string;
}

interface AlertListItem {
  itemList: Array<AlertListItemDetail>;
}

export interface AlertListType {
  list: Array<AlertListItem>;
}

# 透传参数

<template>
  <IxAlert
    v-bind="$attrs">
  </IxAlert>
</template>

通过v-bind="$attrs"实现props参数透传,iduxAlert组件参数可自由传入,无需接收后再转发。

具体参数可查看idux文档 (opens new window)

# 分页按钮位置

原分页按钮是固定在右侧的,且不可配置调整位置。

增加参数:

paginationPosition: {
  type: String,
  default: 'right',
  validator: value => {
    return ['left', 'right'].includes(value);
  },
},

传right时不做处理,保持原样。

传left时通过flex-order将其放在最左侧。

# 引导组件

需求是对指定元素进行高亮显示,其余加遮罩。可点击下一步继续参看引导。

idux没有这类现成的组件,所以自己封装了一个。

# 使用

因为引导步骤都是动态去创建的,强行写成vue组件的形式反而不好。所以对外导出的是一个Guide类,通过实例化去使用。

import { Guide, GuideOptions } from '@/components/IxsGuide';
import { createLegend } from './model/modelLegend';
import { createAlign } from './model/modelAlign';
import { createSave } from './model/modelSave';
import { createDisplay } from './model/modelDisplay';
import { createView } from './model/modelView';

export function showGuide(page: HTMLElement, forceShow: boolean = false) {
  const guideConfig: GuideOptions = {
    key: 'topoGuide',
    forceShow,
    page,
    prevButton: {
      show: true
    },
    nextButton: {
      show: true,
      number: true
    },
    steps: [
      createLegend(),
      createAlign(),
      createSave(),
      createDisplay(),
      createView()
    ]
  };

  new Guide(guideConfig);
}

类型对应如下:

export interface GuideOptions {
  // 用于缓存标识,首次打开页面时自动显示
  key?: string;
  // 忽略缓存强制显示
  forceShow?: boolean;
  // 用于在其中搜索指定元素,优化性能
  page?: HTMLElement;
  // 渲染位置
  renderTarget?: HTMLElement;
  // 显示弹窗关闭按钮
  showClose?: boolean;
  // 上一步
  prevButton?: GuideButton;
  // 下一步
  nextButton?: GuideButton;
  // 引导步骤
  steps?: Array<GuideStep>;
}

# 缓存机制

实例化时传入一个唯一key值即可实现浏览器首次进入页面时才显示引导的功能。

其原理是将key传入localStorage中,每次打开时进行判断。

# steps

steps就是每一个操作步骤

export interface GuideStep {
  target: string;
  style?: Partial<CSSStyleDeclaration>;
  insert?: GuideStepInsert;
  model: GuideStepModel;
  // 图片预加载列表
  imgList?: Array<string>;
}

# target

高亮的目标元素

# insert

需要插入的元素,比如而外的图片等。

# model

弹窗内容

export interface GuideStepModel {
  width?: number;
  height?: number;
  header: string;
  content: VNode;
  getOffset: (r: DOMRect) => GuideModelOffset;
  getTriangle: () => GuideModelTriangle;
}

弹窗通过调用iduxmodal组件去动态创建。

# content

需要注意的是,这里的content是VNode格式,可通过手写h函数或者写好sfc组件导入。

# getOffset

由于modal组件不支持对目标的自适应位置显示,这里添加了getOffset函数,通过目标元素的位置,返回弹窗的偏移量。

# getTriangle

同样的,由于modal组件无三角形指向目标元素的功能,这里添加getTriangle函数

返回指定位置即可实现三角形。

interface GuideModelTriangle {
  position: 'left' | 'right' | 'top' | 'bottom';
  left?: number;
  right?: number;
  top?: number;
  bottom?: number;
}

# 高亮的原理

# 使用border

在body下创建一个div,并使用固定定位。

通过getBoundingClientRect获取目标元素的位置,将创建的div上下左右的边框宽设置为目标元素的位置。

需要注意的是下和右需要通过渲染目标元素的宽高去减才能得到正确的位置。

const { left, top, right, bottom } = this.originTargetDom.getBoundingClientRect();
const { width, height } = this._options.renderTarget!.getBoundingClientRect();
setDomStyle(this.maskDom as HTMLElement, {
  borderTopWidth: `${top}px`,
  borderLeftWidth: `${left}px`,
  borderRightWidth: `${width - right}px`,
  borderBottomWidth: `${height - bottom}px`,
});

然后边框的颜色设置自己想要的颜色,即可实现效果。

# 使用svg

通过svg path

绘制两个方框,并修改point-event实现穿透效果

M: move

H: 横着画

V: 竖着画

Z: 结束

a: 弧度倒角

this.pathEl.setAttribute('d',
  `M${tRight},${tBottom}H0V0H${tRight}V${tBottom}Z
  M${rLeft},${rTop}a0,0,0,0,0-0,0
  V${height + rTop}a0,0,0,0,0,0,0
  H${width + rLeft}a0,0,0,0,0,0-0
  V${rTop}a0,0,0,0,0-0-0Z`
);

参考

https://shepherdjs.dev/

# 弹窗位置自适应

通过floating-ui计算位置

import {
  computePosition,
  autoPlacement,
  shift,
  offset as offsetFn,
  arrow,
  Side,
} from '@floating-ui/dom';
import { GuideModalOffset, GuideModalTriangle } from './types';

export async function usePosition (refer: Element, target: Element, arrowEl: HTMLDivElement) {

  const offset: GuideModalOffset = {};
  const triangle: GuideModalTriangle = {};

  const PADDING = 20;

  const middleware = [
    offsetFn(PADDING),
    autoPlacement(),
    shift({ padding: PADDING }),
  ];

  if (arrowEl) {
    middleware.push(arrow({ element: arrowEl }));
  }

  const { x, y, placement, middlewareData } = await computePosition(refer, target as HTMLElement, {
    middleware,
  });
  offset.left = x;
  offset.top = y;

  // 三角形
  if (arrowEl) {
    // @ts-ignore 插件类型报错
    const { x: arrowX, y: arrowY } = middlewareData.arrow;

    const staticSide = {
      top: 'bottom',
      right: 'left',
      bottom: 'top',
      left: 'right',
    }[placement.split('-')[0]] as Side;

    triangle.left = arrowX !== null ? arrowX : 0;
    triangle.top = arrowY !== null ? arrowY : 0;
    triangle[staticSide] = -6;
  }

  return {
    offset,
    triangle,
  };
}

参考

https://floating-ui.com/docs/getting-started

# 图片资源的预加载

由于引导使用的是动图展示,图片资源还是比较大的,直接渲染出来可能图片要过会才会显示出来,出现抖动效果。

这里添加了图片预加载功能,显示每一步前动态创建img标签去请求图片,待所有资源都请求完后才显示出引导,优化用户体验。

# loading组件

iduxspin组件只能在template标签里写,不支持函数式调用,这里对其简单进行封装。

# 使用

const { destroy } = createIxsSpin();
// 加载图片
await this.preLoadImg(step);
destroy();

# 原理

原理就是通过h函数生成VNode,然后通过render函数挂载到目标元素即可。

import { h, render } from 'vue';

import IxsSpin from './src/Spin.vue';
import { SpinProps } from './src/types';
export * from './src/types';
export function createIxsSpin(target: HTMLElement = document.body, props?: SpinProps) {
  let tip;
  let tipAlign;

  if (props) {
    tip = props.tip;
    tipAlign = props.tipAlign;
  }

  const destroy = (): void => {
    render(null, target);
  };

  const vNode = h(IxsSpin, {
    tip: tip || $i('app.loading'),
    tipAlign,
    destroy,
  });

  render(vNode, target);

  return {
    destroy,
  };
}